All files / src/app/invitations/[token] page.tsx

93.33% Statements 28/30
83.33% Branches 20/24
100% Functions 5/5
93.33% Lines 28/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 1361x                           13x 13x 13x 13x 13x 13x 13x   13x 6x 6x 6x 5x   1x   6x       6x       1x 1x   1x 1x       1x             13x 6x             7x 1x                 6x   13x                                                                                                           1x                
"use client";
 
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Button, LoadingSpinner, ErrorMessage, buttonVariants } from "@/components/ui";
import { apiGet, apiPost } from "@/lib/api-client";
import { useSession } from "@/lib/auth-client";
import type { InvitationPreview } from "@/server/services/InvitationAcceptService";
 
export default function InvitationPage() {
  // Client component: useParams() reads the dynamic segment from context —
  // no `use(params)` Promise-unwrap needed (that's the server-component
  // convention).
  const { token } = useParams<{ token: string }>();
  const router = useRouter();
  const { data: session, isPending: sessionPending } = useSession();
  const [preview, setPreview] = useState<InvitationPreview | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");
  const [accepting, setAccepting] = useState(false);
 
  useEffect(() => {
    const fetchPreview = async () => {
      try {
        const data = await apiGet<InvitationPreview>(`/api/invitations/accept?token=${token}`);
        setPreview(data);
      } catch (err: any) {
        setError(err?.message || "Invitation not found or expired");
      } finally {
        setLoading(false);
      }
    };
 
    fetchPreview();
  }, [token]);
 
  async function handleAccept() {
    setAccepting(true);
    setError("");
 
    try {
      const { collectionId } = await apiPost<{ collectionId: string }>(
        `/api/invitations/accept?token=${token}`,
        {},
      );
      router.push(`/collections/${collectionId}`);
    } catch (err: any) {
      setError(err?.message || "Failed to accept invitation");
      setAccepting(false);
    }
  }
 
  if (loading || sessionPending) {
    return (
      <div className="min-h-[100svh] flex items-center justify-center">
        <LoadingSpinner />
      </div>
    );
  }
 
  if (error || !preview) {
    return (
      <div className="min-h-[100svh] flex items-center justify-center p-4">
        <div className="max-w-sm w-full">
          <ErrorMessage message={error || "Invalid invitation"} />
        </div>
      </div>
    );
  }
 
  const signedIn = Boolean(session?.user);
 
  return (
    <div className="min-h-[100svh] flex items-center justify-center p-4 sm:p-6">
      <div className="max-w-sm w-full bg-glass border border-rim rounded-card shadow-card p-6 sm:p-8">
        <h1 className="text-2xl text-ink mb-2">You&apos;re invited!</h1>
 
        <div className="bg-content-soft border border-hairline rounded-lg p-4 mb-4">
          <p className="text-sm font-medium text-ink">Collection</p>
          <p className="text-lg font-bold text-primary">{preview.collectionName}</p>
          {preview.collectionDescription && (
            <p className="text-xs text-ink-soft mt-2">{preview.collectionDescription}</p>
          )}
        </div>
 
        <div className="space-y-2 mb-6">
          {preview.inviterName && (
            <p className="text-sm text-ink-soft">
              {preview.inviterName} invited you to help sort this collection.
            </p>
          )}
          <p className="text-sm text-ink-soft">
            You&apos;ll sort it your own way — your choices never overwrite anyone else&apos;s.
          </p>
          <p className="text-sm text-ink-soft">
            You&apos;ll be able to add photos, name things, and sort.
          </p>
        </div>
 
        <div className="grid gap-3 sm:grid-cols-2">
          {signedIn ? (
            <Button
              fullWidth
              onClick={handleAccept}
              disabled={accepting}
              aria-label={accepting ? "Accepting" : "Accept invitation"}
            >
              {accepting ? (
                "Accepting..."
              ) : (
                <>
                  <span className="sm:hidden">Accept</span>
                  <span className="hidden sm:inline">Accept invitation</span>
                </>
              )}
            </Button>
          ) : (
            // Styled as a button, but a link is a link — same pattern as
            // SharePanel's "Preview" link (no nested <button> inside <a>).
            <Link
              href={`/login?callbackUrl=/invitations/${token}`}
              className={`${buttonVariants({ variant: "primary" })} w-full`}
            >
              Sign in to accept
            </Link>
          )}
          <Button variant="secondary" fullWidth onClick={() => router.push("/")}>
            Not now
          </Button>
        </div>
      </div>
    </div>
  );
}